设计用户评论详情(类论坛呈现)
概述
用户评论详情页采用类论坛的呈现形式,支持多用户评论、嵌套回复、富文本内容展示。与用户私信(一对一聊天)不同,评论详情是多人参与的话题讨论,具有清晰的层级嵌套结构。页面布局分为左侧主内容区(评论列表+回复编辑器)和右侧信息区(关联主题/用户信息)。
页面结构设计
布局方案
┌─────────────────────────────────────────────────────┐
│ 页面标题:评论详情 │
├───────────────────────────────┬─────────────────────┤
│ 主内容区域 │ 右侧信息栏 │
│ ┌──────────────────────────┐ │ ┌─────────────────┐│
│ │ 主题内容(富文本) │ │ │ 关联课程信息 ││
│ └──────────────────────────┘ │ └─────────────────┘│
│ ┌──────────────────────────┐ │ ┌─────────────────┐│
│ │ 回复编辑器(富文本) │ │ │ 评论者信息 ││
│ └──────────────────────────┘ │ └─────────────────┘│
│ ┌──────────────────────────┐ │ ┌─────────────────┐│
│ │ 评论 1 │ │ │ 标签/分类 ││
│ │ ┌────────────────────┐ │ │ └─────────────────┘│
│ │ │ 回复 1-1(嵌套) │ │ │ │
│ │ │ ┌──────────────┐ │ │ │ │
│ │ │ │ 回复 1-1-1 │ │ │ │ │
│ │ │ └──────────────┘ │ │ │ │
│ │ └────────────────────┘ │ │ │
│ │ 评论 2 ... │ │ │
│ └──────────────────────────┘ │ │
└───────────────────────────────┴─────────────────────┘
text
评论与私信的对比
| 维度 | 用户评论 | 用户私信 |
|---|---|---|
| 参与者 | 多用户 | 一对一 |
| 内容形式 | 富文本(图文+代码块) | 纯文本/简单富文本 |
| 结构 | 嵌套树形结构 | 线性时间线 |
| 可见性 | 公开(所有用户可见) | 私密(仅双方可见) |
| 回复方式 | 引用回复/嵌套回复 | 直接回复 |
| 典型参考 | 论坛/知乎/掘金 | 微信/QQ |
数据结构设计
评论数据类型
// types/comment.ts
interface UserInfo {
id: number
username: string
avatar: string
role?: string
}
export interface Comment {
id: number
content: string // 富文本 HTML 或 Markdown 字符串
contentType: 'html' | 'markdown'
author: UserInfo
createdAt: string
updatedAt?: string
likes: number
isLiked: boolean
/** 嵌套回复(子评论) */
replies?: Comment[]
/** 被回复的评论 ID */
replyToId?: number
replyToUser?: UserInfo
}
export interface CommentDetail {
/** 主题内容 */
topic: {
id: number
title: string
content: string
contentType: 'html' | 'markdown'
author: UserInfo
createdAt: string
tags: string[]
}
/** 评论列表 */
comments: Comment[]
/** 分页信息 */
pagination: {
total: number
page: number
pageSize: number
}
}
typescript
评论列表组件实现
递归嵌套评论组件
<!-- components/CommentItem.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import type { Comment } from './types'
const props = defineProps<{
comment: Comment
depth?: number
}>()
const emit = defineEmits<{
reply: [comment: Comment]
like: [commentId: number]
}>()
const showReplyEditor = ref(false)
const maxDepth = 4
// 超过最大嵌套深度时不再缩进,改为扁平展示
const shouldIndent = (props.depth ?? 0) < maxDepth
</script>
<template>
<div class="comment-item" :class="{ nested: shouldIndent && depth > 0 }">
<div class="comment-header">
<el-avatar :size="32" :src="comment.author.avatar" />
<div class="comment-meta">
<span class="author-name">{{ comment.author.username }}</span>
<span class="comment-time">{{ comment.createdAt }}</span>
</div>
</div>
<!-- 引用回复 -->
<div v-if="comment.replyToUser" class="reply-to">
回复 <span class="reply-user">@{{ comment.replyToUser.username }}</span>
</div>
<!-- 富文本内容 -->
<div class="comment-content" v-html="comment.content" />
<!-- 操作栏 -->
<div class="comment-actions">
<el-button text size="small" @click="emit('like', comment.id)">
<el-icon><Star /></el-icon> {{ comment.likes }}
</el-button>
<el-button text size="small" @click="showReplyEditor = !showReplyEditor">
回复
</el-button>
</div>
<!-- 回复编辑器 -->
<div v-if="showReplyEditor" class="reply-editor">
<RichTextEditor
placeholder="输入回复内容..."
@submit="(content: string) => { emit('reply', comment); showReplyEditor = false }"
/>
</div>
<!-- 递归渲染子回复 -->
<div v-if="comment.replies?.length" class="comment-replies">
<CommentItem
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:depth="(depth ?? 0) + 1"
@reply="(c) => emit('reply', c)"
@like="(id) => emit('like', id)"
/>
</div>
</div>
</template>
<style scoped>
.comment-item {
padding: 16px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.comment-item.nested {
margin-left: 32px;
padding: 12px 0;
border-left: 2px solid var(--el-border-color);
padding-left: 12px;
}
.comment-header {
display: flex;
align-items: center;
gap: 8px;
}
.comment-meta {
display: flex;
flex-direction: column;
}
.author-name {
font-weight: 500;
font-size: 14px;
}
.comment-time {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.reply-to {
font-size: 13px;
color: var(--el-text-color-secondary);
margin: 4px 0;
}
.reply-user {
color: var(--el-color-primary);
}
.comment-content {
margin: 8px 0;
line-height: 1.6;
font-size: 14px;
}
.comment-content :deep(pre) {
background: var(--el-fill-color);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
.comment-actions {
display: flex;
gap: 4px;
}
</style>
vue
评论列表容器
<!-- components/CommentList.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import CommentItem from './CommentItem.vue'
import type { Comment, CommentDetail } from './types'
const props = defineProps<{
data: CommentDetail
}>()
const emit = defineEmits<{
reply: [comment: Comment]
like: [commentId: number]
loadMore: [page: number]
}>()
/** 将扁平评论列表转为树形结构 */
function buildCommentTree(comments: Comment[]): Comment[] {
const map = new Map<number, Comment>()
const roots: Comment[] = []
comments.forEach(c => {
map.set(c.id, { ...c, replies: [] })
})
map.forEach(c => {
if (c.replyToId && map.has(c.replyToId)) {
map.get(c.replyToId)!.replies!.push(c)
} else {
roots.push(c)
}
})
return roots
}
const commentTree = computed(() => buildCommentTree(props.data.comments))
</script>
<template>
<div class="comment-list">
<div class="comment-total">
共 {{ data.pagination.total }} 条评论
</div>
<CommentItem
v-for="comment in commentTree"
:key="comment.id"
:comment="comment"
:depth="0"
@reply="(c) => emit('reply', c)"
@like="(id) => emit('like', id)"
/>
<div v-if="data.pagination.page * data.pagination.pageSize < data.pagination.total" class="load-more">
<el-button text @click="emit('loadMore', data.pagination.page + 1)">
加载更多评论
</el-button>
</div>
</div>
</template>
vue
右侧信息栏
<!-- components/CommentSidebar.vue -->
<script setup lang="ts">
import type { CommentDetail } from './types'
defineProps<{
data: CommentDetail
}>()
</script>
<template>
<aside class="comment-sidebar">
<!-- 关联课程信息 -->
<el-card shadow="never">
<template #header>关联课程</template>
<p>{{ data.topic.title }}</p>
<div class="tags">
<el-tag v-for="tag in data.topic.tags" :key="tag" size="small">{{ tag }}</el-tag>
</div>
</el-card>
<!-- 评论者信息 -->
<el-card shadow="never">
<template #header>发起人</template>
<div class="author-info">
<el-avatar :size="40" :src="data.topic.author.avatar" />
<span>{{ data.topic.author.username }}</span>
</div>
</el-card>
</aside>
</template>
<style scoped>
.comment-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
vue
实践要点
- 评论采用嵌套树形结构渲染,使用递归组件
CommentItem支持任意深度嵌套 - 设置最大嵌套深度(如 4 层),超出后改为扁平展示避免缩进过深
- 富文本内容通过
v-html渲染服务端返回的 HTML 字符串,需注意 XSS 防护 - 右侧信息栏展示关联课程、评论者信息等辅助内容,帮助用户了解上下文
- 回复编辑器使用之前开发的富文本组件(Vditor),支持 Markdown 格式编辑
- 扁平数据转树形结构的
buildCommentTree函数可在前端完成,减少后端复杂度
↑